diff --git a/web/app.react.js b/web/app.react.js index 5c842e0bc..967e8cd2b 100644 --- a/web/app.react.js +++ b/web/app.react.js @@ -1,380 +1,384 @@ // @flow import { config as faConfig } from '@fortawesome/fontawesome-svg-core'; import { faCalendar, faComments } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import classNames from 'classnames'; import invariant from 'invariant'; import _isEqual from 'lodash/fp/isEqual'; import * as React from 'react'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { useDispatch } from 'react-redux'; import { fetchEntriesActionTypes, updateCalendarQueryActionTypes, } from 'lib/actions/entry-actions'; import { createLoadingStatusSelector, combineLoadingStatuses, } from 'lib/selectors/loading-selectors'; import { mostRecentReadThreadSelector, unreadCount, } from 'lib/selectors/thread-selectors'; import { isLoggedIn } from 'lib/selectors/user-selectors'; import type { LoadingStatus } from 'lib/types/loading-types'; import type { Dispatch } from 'lib/types/redux-types'; import { verifyField, type ServerVerificationResult, } from 'lib/types/verify-types'; import { registerConfig } from 'lib/utils/config'; import AccountBar from './account-bar.react'; import Calendar from './calendar/calendar.react'; import Chat from './chat/chat.react'; import InputStateContainer from './input/input-state-container.react'; import LoadingIndicator from './loading-indicator.react'; import ResetPasswordModal from './modals/account/reset-password-modal.react'; import VerificationModal from './modals/account/verification-modal.react'; import FocusHandler from './redux/focus-handler.react'; import { type NavInfo, updateNavInfoActionType } from './redux/redux-setup'; import { useSelector } from './redux/redux-utils'; import VisibilityHandler from './redux/visibility-handler.react'; import history from './router-history'; import Splash from './splash/splash.react'; import css from './style.css'; import getTitle from './title/getTitle'; import { canonicalURLFromReduxState, navInfoFromURL } from './url-utils'; // We want Webpack's css-loader and style-loader to handle the Fontawesome CSS, // so we disable the autoAddCss logic and import the CSS file. Otherwise every // icon flashes huge for a second before the CSS is loaded. import '@fortawesome/fontawesome-svg-core/styles.css'; faConfig.autoAddCss = false; registerConfig({ // We can't securely cache credentials on web, so we have no way to recover // from a cookie invalidation resolveInvalidatedCookie: null, // We use httponly cookies on web to protect against XSS attacks, so we have // no access to the cookies from JavaScript setCookieOnRequest: false, setSessionIDOnRequest: true, // Never reset the calendar range calendarRangeInactivityLimit: null, platformDetails: { platform: 'web' }, }); type BaseProps = {| +location: { +pathname: string, ... }, |}; type Props = {| ...BaseProps, // Redux state +navInfo: NavInfo, +serverVerificationResult: ?ServerVerificationResult, +entriesLoadingStatus: LoadingStatus, +loggedIn: boolean, +mostRecentReadThread: ?string, +activeThreadCurrentlyUnread: boolean, +viewerID: ?string, +unreadCount: number, // Redux dispatch functions +dispatch: Dispatch, |}; type State = {| +currentModal: ?React.Node, |}; class App extends React.PureComponent { state: State = { currentModal: null, }; componentDidMount() { const { navInfo, serverVerificationResult } = this.props; if (navInfo.verify && serverVerificationResult) { if (serverVerificationResult.field === verifyField.RESET_PASSWORD) { this.showResetPasswordModal(); } else { - const newURL = canonicalURLFromReduxState( - { ...navInfo, verify: null }, - this.props.location.pathname, + this.setModal( + , ); - history.replace(newURL); - this.setModal(); } } - if (this.props.loggedIn) { - const newURL = canonicalURLFromReduxState( - navInfo, - this.props.location.pathname, - ); - if (this.props.location.pathname !== newURL) { - history.replace(newURL); - } - } else if (this.props.location.pathname !== '/') { - history.replace('/'); + const newURL = canonicalURLFromReduxState( + navInfo, + this.props.location.pathname, + this.props.loggedIn, + ); + if (this.props.location.pathname !== newURL) { + history.replace(newURL); } } componentDidUpdate(prevProps: Props) { - if (this.props.loggedIn) { - if (this.props.location.pathname !== prevProps.location.pathname) { - const newNavInfo = navInfoFromURL(this.props.location.pathname, { - navInfo: this.props.navInfo, - }); - if (!_isEqual(newNavInfo)(this.props.navInfo)) { - this.props.dispatch({ - type: updateNavInfoActionType, - payload: newNavInfo, - }); - } - } else if (!_isEqual(this.props.navInfo)(prevProps.navInfo)) { - const newURL = canonicalURLFromReduxState( - this.props.navInfo, - this.props.location.pathname, - ); - if (newURL !== this.props.location.pathname) { - history.push(newURL); + if (!_isEqual(this.props.navInfo)(prevProps.navInfo)) { + const { navInfo, serverVerificationResult } = this.props; + if ( + navInfo.verify && + !prevProps.navInfo.verify && + serverVerificationResult + ) { + if (serverVerificationResult.field === verifyField.RESET_PASSWORD) { + this.showResetPasswordModal(); + } else { + this.setModal( + , + ); } + } else if (!navInfo.verify && prevProps.navInfo.verify) { + this.clearModal(); } - } - const justLoggedIn = this.props.loggedIn && !prevProps.loggedIn; - if (justLoggedIn) { + const newURL = canonicalURLFromReduxState( + navInfo, + this.props.location.pathname, + this.props.loggedIn, + ); + if (newURL !== this.props.location.pathname) { + history.push(newURL); + } + } else if (this.props.location.pathname !== prevProps.location.pathname) { + const newNavInfo = navInfoFromURL(this.props.location.pathname, { + navInfo: this.props.navInfo, + }); + if (!_isEqual(newNavInfo)(this.props.navInfo)) { + this.props.dispatch({ + type: updateNavInfoActionType, + payload: newNavInfo, + }); + } + } else if (this.props.loggedIn !== prevProps.loggedIn) { const newURL = canonicalURLFromReduxState( this.props.navInfo, this.props.location.pathname, + this.props.loggedIn, ); - if (this.props.location.pathname !== newURL) { + if (newURL !== this.props.location.pathname) { history.replace(newURL); } } - - const justLoggedOut = !this.props.loggedIn && prevProps.loggedIn; - if (justLoggedOut && this.props.location.pathname !== '/') { - history.replace('/'); - } - - const { navInfo, serverVerificationResult } = this.props; - if ( - serverVerificationResult && - serverVerificationResult.field === verifyField.RESET_PASSWORD - ) { - if (navInfo.verify && !prevProps.navInfo.verify) { - this.showResetPasswordModal(); - } else if (!navInfo.verify && prevProps.navInfo.verify) { - this.clearModal(); - } - } } showResetPasswordModal() { const newURL = canonicalURLFromReduxState( { ...this.props.navInfo, verify: null, }, this.props.location.pathname, + this.props.loggedIn, ); const onClose = () => history.push(newURL); const onSuccess = () => history.replace(newURL); this.setModal( , ); } render() { let content; if (this.props.loggedIn) { content = this.renderMainContent(); } else { content = ( ); } return ( {content} {this.state.currentModal} ); } renderMainContent() { const calendarNavClasses = classNames({ [css['current-tab']]: this.props.navInfo.tab === 'calendar', }); const chatNavClasses = classNames({ [css['current-tab']]: this.props.navInfo.tab === 'chat', }); let mainContent; if (this.props.navInfo.tab === 'calendar') { mainContent = ( ); } else if (this.props.navInfo.tab === 'chat') { mainContent = ; } const { viewerID, unreadCount: curUnreadCount } = this.props; invariant(viewerID, 'should be set'); let chatBadge = null; if (curUnreadCount > 0) { chatBadge =
{curUnreadCount}
; } return (
{mainContent}
); } setModal = (modal: ?React.Node) => { this.setState({ currentModal: modal }); }; - clearModal = () => { + clearModal() { this.setModal(null); + } + + clearVerificationModal = () => { + const navInfo = { ...this.props.navInfo, verify: null }; + const newURL = canonicalURLFromReduxState( + navInfo, + this.props.location.pathname, + this.props.loggedIn, + ); + if (newURL !== this.props.location.pathname) { + history.push(newURL); + } }; onClickCalendar = (event: SyntheticEvent) => { event.preventDefault(); this.props.dispatch({ type: updateNavInfoActionType, payload: { tab: 'calendar' }, }); }; onClickChat = (event: SyntheticEvent) => { event.preventDefault(); this.props.dispatch({ type: updateNavInfoActionType, payload: { tab: 'chat', activeChatThreadID: this.props.activeThreadCurrentlyUnread ? this.props.mostRecentReadThread : this.props.navInfo.activeChatThreadID, }, }); }; } const fetchEntriesLoadingStatusSelector = createLoadingStatusSelector( fetchEntriesActionTypes, ); const updateCalendarQueryLoadingStatusSelector = createLoadingStatusSelector( updateCalendarQueryActionTypes, ); export default React.memo(function ConnectedApp(props: BaseProps) { const activeChatThreadID = useSelector( (state) => state.navInfo.activeChatThreadID, ); const navInfo = useSelector((state) => state.navInfo); const serverVerificationResult = useSelector( (state) => state.serverVerificationResult, ); const fetchEntriesLoadingStatus = useSelector( fetchEntriesLoadingStatusSelector, ); const updateCalendarQueryLoadingStatus = useSelector( updateCalendarQueryLoadingStatusSelector, ); const entriesLoadingStatus = combineLoadingStatuses( fetchEntriesLoadingStatus, updateCalendarQueryLoadingStatus, ); const loggedIn = useSelector(isLoggedIn); const mostRecentReadThread = useSelector(mostRecentReadThreadSelector); const activeThreadCurrentlyUnread = useSelector( (state) => !activeChatThreadID || !!state.threadStore.threadInfos[activeChatThreadID]?.currentUser.unread, ); const viewerID = useSelector( (state) => state.currentUserInfo && state.currentUserInfo.id, ); const boundUnreadCount = useSelector(unreadCount); React.useEffect(() => { document.title = getTitle(boundUnreadCount); }, [boundUnreadCount]); const dispatch = useDispatch(); return ( ); }); diff --git a/web/calendar/calendar.react.js b/web/calendar/calendar.react.js index 6ecb31892..893bb733c 100644 --- a/web/calendar/calendar.react.js +++ b/web/calendar/calendar.react.js @@ -1,281 +1,287 @@ // @flow import { faFilter } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import dateFormat from 'dateformat'; import invariant from 'invariant'; import * as React from 'react'; import { updateCalendarQueryActionTypes, updateCalendarQuery, } from 'lib/actions/entry-actions'; import { currentDaysToEntries } from 'lib/selectors/thread-selectors'; +import { isLoggedIn } from 'lib/selectors/user-selectors'; import { type EntryInfo, type CalendarQuery, type CalendarQueryUpdateResult, type CalendarQueryUpdateStartingPayload, } from 'lib/types/entry-types'; import { type DispatchActionPromise, useDispatchActionPromise, useServerCall, } from 'lib/utils/action-utils'; import { getDate, dateString, startDateForYearAndMonth, endDateForYearAndMonth, } from 'lib/utils/date-utils'; import { type NavInfo } from '../redux/redux-setup'; import { useSelector } from '../redux/redux-utils'; import { yearAssertingSelector, monthAssertingSelector, webCalendarQuery, } from '../selectors/nav-selectors'; import { canonicalURLFromReduxState } from '../url-utils'; import css from './calendar.css'; import Day from './day.react'; import FilterPanel from './filter-panel.react'; type BaseProps = {| +setModal: (modal: ?React.Node) => void, +url: string, |}; type Props = {| ...BaseProps, +year: number, +month: number, +daysToEntries: { [dayString: string]: EntryInfo[] }, +navInfo: NavInfo, +currentCalendarQuery: () => CalendarQuery, + +loggedIn: boolean, +dispatchActionPromise: DispatchActionPromise, +updateCalendarQuery: ( calendarQuery: CalendarQuery, reduxAlreadyUpdated?: boolean, ) => Promise, |}; type State = {| filterPanelOpen: boolean, |}; class Calendar extends React.PureComponent { state: State = { filterPanelOpen: false, }; getDate( dayOfMonth: number, monthInput: ?number = undefined, yearInput: ?number = undefined, ) { return getDate( yearInput ? yearInput : this.props.year, monthInput ? monthInput : this.props.month, dayOfMonth, ); } prevMonthDates() { const { year, month } = this.props; const lastMonthDate = getDate(year, month - 1, 1); const prevYear = lastMonthDate.getFullYear(); const prevMonth = lastMonthDate.getMonth() + 1; return { startDate: startDateForYearAndMonth(prevYear, prevMonth), endDate: endDateForYearAndMonth(prevYear, prevMonth), }; } nextMonthDates() { const { year, month } = this.props; const nextMonthDate = getDate(year, month + 1, 1); const nextYear = nextMonthDate.getFullYear(); const nextMonth = nextMonthDate.getMonth() + 1; return { startDate: startDateForYearAndMonth(nextYear, nextMonth), endDate: endDateForYearAndMonth(nextYear, nextMonth), }; } render() { const { year, month } = this.props; const monthName = dateFormat(getDate(year, month, 1), 'mmmm'); const prevURL = canonicalURLFromReduxState( { ...this.props.navInfo, ...this.prevMonthDates() }, this.props.url, + this.props.loggedIn, ); const nextURL = canonicalURLFromReduxState( { ...this.props.navInfo, ...this.nextMonthDates() }, this.props.url, + this.props.loggedIn, ); const lastDayOfMonth = this.getDate(0, this.props.month + 1); const totalDaysInMonth = lastDayOfMonth.getDate(); const firstDayToPrint = 1 - this.getDate(1).getDay(); const lastDayToPrint = totalDaysInMonth + 6 - lastDayOfMonth.getDay(); const rows = []; let columns = []; let week = 1; let tabIndex = 1; for ( let curDayOfMonth = firstDayToPrint; curDayOfMonth <= lastDayToPrint; curDayOfMonth++ ) { if (curDayOfMonth < 1 || curDayOfMonth > totalDaysInMonth) { columns.push(); } else { const dayString = dateString( this.props.year, this.props.month, curDayOfMonth, ); const entries = this.props.daysToEntries[dayString]; invariant( entries, 'the currentDaysToEntries selector should make sure all dayStrings ' + `in the current range have entries, but ${dayString} did not`, ); columns.push( , ); tabIndex += entries.length; } if (columns.length === 7) { rows.push({columns}); columns = []; } } let filterPanel = null; let calendarContentStyle = null; let filterButtonStyle = null; if (this.state.filterPanelOpen) { filterPanel = ; calendarContentStyle = { marginLeft: '300px' }; filterButtonStyle = { backgroundColor: 'rgba(0,0,0,0.67)' }; } return (
{filterPanel}
Filters

<
{' '} {monthName} {year}{' '}
>

{rows}
Sunday Monday Tuesday Wednesday Thursday Friday Saturday
); } toggleFilters = (event: SyntheticEvent) => { event.preventDefault(); this.setState({ filterPanelOpen: !this.state.filterPanelOpen }); }; onClickPrevURL = (event: SyntheticEvent) => { event.preventDefault(); const currentCalendarQuery = this.props.currentCalendarQuery(); const newCalendarQuery = { ...currentCalendarQuery, ...this.prevMonthDates(), }; this.props.dispatchActionPromise( updateCalendarQueryActionTypes, this.props.updateCalendarQuery(newCalendarQuery, true), undefined, ({ calendarQuery: newCalendarQuery }: CalendarQueryUpdateStartingPayload), ); }; onClickNextURL = (event: SyntheticEvent) => { event.preventDefault(); const currentCalendarQuery = this.props.currentCalendarQuery(); const newCalendarQuery = { ...currentCalendarQuery, ...this.nextMonthDates(), }; this.props.dispatchActionPromise( updateCalendarQueryActionTypes, this.props.updateCalendarQuery(newCalendarQuery, true), undefined, ({ calendarQuery: newCalendarQuery }: CalendarQueryUpdateStartingPayload), ); }; } export default React.memo(function ConnectedCalendar( props: BaseProps, ) { const year = useSelector(yearAssertingSelector); const month = useSelector(monthAssertingSelector); const daysToEntries = useSelector(currentDaysToEntries); const navInfo = useSelector((state) => state.navInfo); const currentCalendarQuery = useSelector(webCalendarQuery); + const loggedIn = useSelector(isLoggedIn); const callUpdateCalendarQuery = useServerCall(updateCalendarQuery); const dispatchActionPromise = useDispatchActionPromise(); return ( ); }); diff --git a/web/url-utils.js b/web/url-utils.js index e89836527..6e3041840 100644 --- a/web/url-utils.js +++ b/web/url-utils.js @@ -1,105 +1,113 @@ // @flow import invariant from 'invariant'; import { startDateForYearAndMonth, endDateForYearAndMonth, } from 'lib/utils/date-utils'; import { infoFromURL } from 'lib/utils/url-utils'; import type { NavInfo } from './redux/redux-setup'; import { yearExtractor, monthExtractor } from './selectors/nav-selectors'; -function canonicalURLFromReduxState(navInfo: NavInfo, currentURL: string) { +function canonicalURLFromReduxState( + navInfo: NavInfo, + currentURL: string, + loggedIn: boolean, +) { const urlInfo = infoFromURL(currentURL); const today = new Date(); - let newURL = `/${navInfo.tab}/`; + let newURL = `/`; - if (navInfo.tab === 'calendar') { - const year = yearExtractor(navInfo.startDate, navInfo.endDate); - if (urlInfo.year !== undefined) { - invariant( - year !== null && year !== undefined, - `${navInfo.startDate} and ${navInfo.endDate} aren't in the same year`, - ); - newURL += `year/${year}/`; - } else if ( - year !== null && - year !== undefined && - year !== today.getFullYear() - ) { - newURL += `year/${year}/`; - } + if (loggedIn) { + newURL += `${navInfo.tab}/`; + if (navInfo.tab === 'calendar') { + const { startDate, endDate } = navInfo; + const year = yearExtractor(startDate, endDate); + if (urlInfo.year !== undefined) { + invariant( + year !== null && year !== undefined, + `${startDate} and ${endDate} aren't in the same year`, + ); + newURL += `year/${year}/`; + } else if ( + year !== null && + year !== undefined && + year !== today.getFullYear() + ) { + newURL += `year/${year}/`; + } - const month = monthExtractor(navInfo.startDate, navInfo.endDate); - if (urlInfo.month !== undefined) { - invariant( - month !== null && month !== undefined, - `${navInfo.startDate} and ${navInfo.endDate} aren't in the same month`, - ); - newURL += `month/${month}/`; - } else if ( - month !== null && - month !== undefined && - month !== today.getMonth() + 1 - ) { - newURL += `month/${month}/`; - } - } else if (navInfo.tab === 'chat') { - const activeChatThreadID = navInfo.activeChatThreadID; - if (activeChatThreadID) { - newURL += `thread/${activeChatThreadID}/`; + const month = monthExtractor(startDate, endDate); + if (urlInfo.month !== undefined) { + invariant( + month !== null && month !== undefined, + `${startDate} and ${endDate} aren't in the same month`, + ); + newURL += `month/${month}/`; + } else if ( + month !== null && + month !== undefined && + month !== today.getMonth() + 1 + ) { + newURL += `month/${month}/`; + } + } else if (navInfo.tab === 'chat') { + const activeChatThreadID = navInfo.activeChatThreadID; + if (activeChatThreadID) { + newURL += `thread/${activeChatThreadID}/`; + } } } if (navInfo.verify) { newURL += `verify/${navInfo.verify}/`; } return newURL; } // Given a URL, this function parses out a navInfo object, leaving values as // default if they are unspecified. function navInfoFromURL( url: string, backupInfo: {| now?: Date, navInfo?: NavInfo |}, ): NavInfo { const urlInfo = infoFromURL(url); const { navInfo } = backupInfo; const now = backupInfo.now ? backupInfo.now : new Date(); let year = urlInfo.year; if (!year && navInfo) { year = yearExtractor(navInfo.startDate, navInfo.endDate); } if (!year) { year = now.getFullYear(); } let month = urlInfo.month; if (!month && navInfo) { month = monthExtractor(navInfo.startDate, navInfo.endDate); } if (!month) { month = now.getMonth() + 1; } let activeChatThreadID = null; if (urlInfo.thread) { activeChatThreadID = urlInfo.thread.toString(); } else if (navInfo) { activeChatThreadID = navInfo.activeChatThreadID; } return { tab: urlInfo.chat ? 'chat' : 'calendar', startDate: startDateForYearAndMonth(year, month), endDate: endDateForYearAndMonth(year, month), activeChatThreadID, verify: urlInfo.verify ? urlInfo.verify : null, }; } export { canonicalURLFromReduxState, navInfoFromURL };